我們今天要把 crawler
函式及 saveData
函式寫好!
crawler
函式我們就依照昨天的想法把 crawler
函式寫出來,並把 parseArticle
較為繁重複雜的工作分離出去。
async function crawler(startURL) {
// 爬到的資料,用唯一鍵值的 Map 存單一 Block 的所有文章資料
const result = new Map();
// nextURL 是下一頁的網址,如果沒有下一頁就是 null
let nextURL = startURL;
let page = 1;
// 一個 Page 一個 Page 的抓
while (nextURL) {
console.log(`Crawling Page ${page}`);
// html 會是純文字網頁內容
const html = await fetch(nextURL).then((res) => res.text());
// dom 是 JSDOM 物件,我們可以使用一般操作 DOM 的方法來取得資料
const dom = new JSDOM(html);
const document = dom.window.document;
// 取得文章列表
const articles = document.querySelectorAll("li.ir-list");
// 一個一個文章處裡
for (const article of articles) {
const parsed = parseArticle(article);
// 以文章網址為 key 將文章資料存入 result
result.set(parsed.link, parsed);
}
// 取得下一頁的網址
nextURL = document.querySelector(".pagination > .active")?.nextElementSibling?.querySelector("a")?.href;
page++;
}
// 回傳陣列型態的 result
return [...result.values()];
}
parseArticle
函式parseArticle
的單一職責就是把資料從 DOM 中抽出變成方便取用的資料。
因為在處理文章時不知道會不會被莫名其妙的單一文章害死,所以用 try-catch
包起來。
然後呢,我們需要去打開 F12 Console 去調查看看目標元素的 CSS Selector 怎麼寫。
function parseArticle(article) {
try {
// 用事先調查的 CSS Selector 取得各項資料
const type = article.querySelector(".group-badge__name").textContent.trim();
const series = article.querySelector(".ir-list__group-topic > a").textContent.trim();
const title = article.querySelector(".ir-list__title > a").textContent.trim();
const link = article.querySelector(".ir-list__title > a").href;
// 關於 info 的部分比較複雜,先以 \n 為刀切開字串,再移除空白字元
const info = article
.querySelector(".ir-list__info")
.textContent.trim()
.split("\n")
.map((x) => x.trim());
// 然後我們會從切開的 info 中抓出作者、發布時間、觀看數、團隊名稱(如果有的話)
let author, date, view, team;
// 根據觀察,有團隊的話會有 8 個字串,沒有的話則有 4 個字串
if (info.length === 4 || info.length === 8) {
author = info[0];
// date 的格式為 [YYYY, MM, DD]
date = info[3]
.match(/(\d{4})-(\d{2})-(\d{2})/)
.slice(1, 4)
.map(Number);
view = +info[3].match(/(\d+?) 次瀏覽/)[1];
if (info.length === 8) team = info[7].substr(2);
else team = null;
} else {
// 發生未符合 4 或 8 個字串的情況,就拋出錯誤
throw new Error(`${title}: Invalid Article Info ${info.length}`);
}
// 回傳解析後的資料
return { type, series, title, link, author, date, view, team };
} catch (err) {
// 如果中間不幸發生錯誤,就顯示錯誤並跳過這篇文章
console.error("Article Parse Error", err.message);
}
}
saveData
函式我們預計一天抓取四次資料。
關於儲存資料的部分,為了避免全部的資料都存在同一個檔案讓單一檔案太大又或者是每次抓取的資料都放在獨立檔案中導致檔案太多,所以我們折衷把一天抓到的資料都放在同一個檔案。
用了 Node.js 總要學點檔案操作是吧。絕對不是我懶得建資料庫,才用檔案系統。
這裡先說聲抱歉,昨天忘了加上 path
Package 了,雖然說不是一定需要,但最好還是用 path
去處理路徑。
const path = require("path");
function saveData(data) {
// 紀錄現在時間
const d = new Date();
// 檔案名稱及路徑,格式為 YYYY-MM-DD.json
const filename = `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")}.json`;
const filePath = path.join(__dirname, "../data", filename);
// file 是已經存在的檔案,如果檔案不存在就會是空的 {}
let file = {};
if (fs.existsSync(filePath)) file = JSON.parse(fs.readFileSync(filePath));
// 將新的資料添加到 file 中
const hour = d.getHours().toString().padStart(2, "0");
file[hour] = data;
// 將 file 寫入檔案,__dirname 是執行的位置
if (!fs.existsSync(path.join(__dirname, "../data"))) fs.mkdirSync(path.join(__dirname, "../data"));
fs.writeFileSync(filePath, JSON.stringify(file));
}
接著在 Terminal 打
node index.js
執行爬蟲!
這樣我們的爬蟲就完成了,但是不是覺得有點慢呢?
395.3 秒爬了 9890 篇文章,平均一秒爬取 25 篇文章,也就是 2.5 頁。
明天我們就試著來優化一下效能好了。
以 9/23 20:00 ~ 9/24 20:00 文章觀看數增加值排名
+2318
Day 1 無限手套 AWS 版:掌控一切的 5 + 1 雲端必學主題
+1852
Day 2 AWS 是什麼?又為何企業這麼需要 AWS 人才?
+1851
Day 3 雲端四大平台比較:AWS . GCP . Azure . Alibaba
+1773
Day 4 網路寶石:AWS VPC Region/AZ vs VPC/Subnet 關係介紹
+1758
Day 5 網路寶石:AWS VPC 架構 Routes & Security (上)
+1714
Day 7 網路寶石:【Lab】VPC外網 Public Subnet to the Internet (IGW) (上)
+1708
Day 6 網路寶石:AWS VPC 架構 Routes & Security (下)
+1676
Day 15 儲存寶石:S3 架構 & 版本控管 (Versioning)
+1670
Day 10 運算寶石:EC2 儲存資源 Instance Store vs Elastic Block Storage (EBS)
+1663
Day 14 儲存寶石:S3是什麼? S3 vs EBS 方案比較
觀看數增加速度創新高